/* 
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 * 
 *     http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.callimachusproject.logging;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.util.Hashtable;
import java.util.logging.*;
import java.util.zip.GZIPOutputStream;

/**
 * A <code>Handler</code> writes description of logging event into a specified
 * file or a rotating set of files.
 * <p>
 * If a set of files are used, when a given amount of data has been written to
 * one file, this file is closed, and another file is opened. The name of these
 * files are generated by given name pattern, see below for details.
 * </p>
 * <p>
 * By default the IO buffering mechanism is enabled, but when each log record is
 * complete, it is flushed out.
 * </p>
 * <p>
 * <code>XMLFormatter</code> is default formatter for <code>FileHandler</code>.
 * </p>
 * <p>
 * <code>MemoryHandler</code> will read following <code>LogManager</code>
 * properties for initialization, if given properties are not defined or has
 * invalid values, default value will be used.
 * <ul>
 * <li>java.util.logging.FileHandler.level specifies the level for this
 * <code>Handler</code>, defaults to <code>Level.ALL</code>.</li>
 * <li>java.util.logging.FileHandler.filter specifies the <code>Filter</code>
 * class name, defaults to no <code>Filter</code>.</li>
 * <li>java.util.logging.FileHandler.formatter specifies the
 * <code>Formatter</code> class, defaults to
 * <code>java.util.logging.XMLFormatter</code>.</li>
 * <li>java.util.logging.FileHandler.encoding specifies the character set
 * encoding name, defaults to the default platform encoding.</li>
 * <li>java.util.logging.FileHandler.limit specifies an maximum bytes to write
 * to any one file, defaults to zero, which means no limit.</li>
 * <li>java.util.logging.FileHandler.count specifies how many output files to
 * rotate, defaults to 1.</li>
 * <li>java.util.logging.FileHandler.pattern specifies name pattern for the
 * output files. See below for details. Defaults to "%h/java%u.log".</li>
 * <li>java.util.logging.FileHandler.append specifies whether this
 * <code>FileHandler</code> should append onto existing files, defaults to
 * false.</li>
 * </ul>
 * </p>
 * <p>
 * Name pattern is a string that may includes some special sub-strings, which
 * will be replaced to generate output files:
 * <ul>
 * <li>"/" represents the local pathname separator</li>
 * <li>"%t" represents the system temporary directory</li>
 * <li>"%h" represents the home directory of current user, which is specified
 * by "user.home" system property</li>
 * <li>"%g" represents the generation number to distinguish rotated logs</li>
 * <li>"%u" represents a unique number to resolve conflicts</li>
 * <li>"%%" represents percent sign character '%'</li>
 * </ul>
 * </p>
 * Normally, the generation numbers are not larger than given file count and
 * follow the sequence 0, 1, 2.... If the file count is larger than one, but the
 * generation field("%g") has not been specified in the pattern, then the
 * generation number after a dot will be added to the end of the file name,
 * </p>
 * <p>
 * The "%u" unique field is used to avoid conflicts and set to 0 at first. If
 * one <code>FileHandler</code> tries to open the filename which is currently
 * in use by another process, it will repeatedly increment the unique number
 * field and try again. If the "%u" component has not been included in the file
 * name pattern and some contention on a file does occur then a unique numerical
 * value will be added to the end of the filename in question immediately to the
 * right of a dot. The unique IDs for avoiding conflicts is only guaranteed to
 * work reliably when using a local disk file system.
 * </p>
 * 
 */
public class FileHandler extends StreamHandler {

    private static final String LCK_EXT = ".lck"; //$NON-NLS-1$

    private static final int DEFAULT_COUNT = 1;

    private static final int DEFAULT_LIMIT = 0;

    private static final boolean DEFAULT_GZIP = false;

    private static final boolean DEFAULT_APPEND = false;

    private static final String DEFAULT_PATTERN = "%h/java%u.log"; //$NON-NLS-1$

    // maintain all file locks hold by this process
    private static final Hashtable<String, FileLock> allLocks = new Hashtable<String, FileLock>();

    // the count of files which the output cycle through
    private int count;

    // the size limitation in byte of log file
    private int limit;

    // whether the FileHandler should compress file when renaming it
    private boolean gzip;

    // whether the FileHandler should open a existing file for output in append
    // mode
    private boolean append;

    // the pattern for output file name
    private String pattern;

    // maintain a LogManager instance for convenience
    private LogManager manager;

    // output stream, which can measure the output file length
    private MeasureOutputStream output;

    // used output file
    private File[] files;

    // output file lock
    FileLock lock = null;

    // current output file name
    String fileName = null;

    // current unique ID
    int uniqueID = -1;

    /**
     * Construct a <code>FileHandler</code> using <code>LogManager</code>
     * properties or their default value
     * 
     * @throws IOException
     *             if any IO exception happened
     * @throws SecurityException
     *             if security manager exists and it determines that caller does
     *             not have the required permissions to control this handler,
     *             required permissions include
     *             <code>LogPermission("control")</code> and other permission
     *             like <code>FilePermission("write")</code>, etc.
     */
    public FileHandler() throws IOException {
        // check access
        manager = LogManager.getLogManager();
        manager.checkAccess();
        initProperties();
        initOutputFiles();
    }

    private void initOutputFiles() throws FileNotFoundException, IOException {
        while (true) {
            // try to find a unique file which is not locked by other process
            uniqueID++;
            // FIXME: improve performance here
            for (int generation = 0; generation < count; generation++) {
                // cache all file names for rotation use
                files[generation] = new File(parseFileName(generation));
            }
            fileName = files[0].getCanonicalPath();
            synchronized (allLocks) {
                /*
                 * if current process has held lock for this fileName continue
                 * to find next file
                 */
                if (null != allLocks.get(fileName)) {
                    continue;
                }
                if (files[0].exists()
                        && (!append || files[0].length() >= limit)) {
                    for (int i = count - 1; i > 0; i--) {
                        if (files[i].exists()) {
                            files[i].delete();
                        }
                        renameTo(files[i - 1], files[i]);
                    }
                } else {
                	files[0].getParentFile().mkdirs();
                	setPermissions(files[0]);
                }
                FileOutputStream fileStream = new FileOutputStream(fileName
                        + LCK_EXT);
                FileChannel channel = fileStream.getChannel();
                /*
                 * if lock is unsupported and IOException thrown, just let the
                 * IOException throws out and exit otherwise it will go into an
                 * undead cycle
                 */
                lock = channel.tryLock();
                if (null == lock) {
                    try {
                        fileStream.close();
                    } catch (Exception e) {
                        // ignore
                    }
                    continue;
                }
                allLocks.put(fileName, lock);
                break;
            }
        }
        output = new MeasureOutputStream(new BufferedOutputStream(
                new FileOutputStream(fileName, append)), files[0].length());
        setOutputStream(output);
    }

	private void initProperties() {
        String className = this.getClass().getName();
        pattern = getStringProperty(className + ".pattern", DEFAULT_PATTERN);
        if (null == pattern || "".equals(pattern)) { //$NON-NLS-1$
            // logging.19=Pattern cannot be empty
            throw new NullPointerException("Pattern cannot be empty"); //$NON-NLS-1$
        }
        gzip = getBooleanProperty(className + ".gzip", DEFAULT_GZIP);
        append = getBooleanProperty(className + ".append", DEFAULT_APPEND);
        count = getIntProperty(className + ".count", DEFAULT_COUNT);
        limit = getIntProperty(className + ".limit", DEFAULT_LIMIT);
        count = count < 1 ? DEFAULT_COUNT : count;
        limit = limit < 0 ? DEFAULT_LIMIT : limit;
        files = new File[count];
    }

    void findNextGeneration() {
    	super.close();
    	try {
    		for (int i = count - 1; i > 0; i--) {
    			if (files[i].exists()) {
    				files[i].delete();
    			}
    			renameTo(files[i - 1], files[i]);
    		}
    		setPermissions(files[0]);
    		output = new MeasureOutputStream(new BufferedOutputStream(
    				new FileOutputStream(files[0])));
    	} catch (FileNotFoundException e1) {
    		// logging.1A=Error happened when open log file.
    		this.getErrorManager().error("Error happened when open log file.", //$NON-NLS-1$
    				e1, ErrorManager.OPEN_FAILURE);
    	} catch (IOException e1) {
    		this.getErrorManager().error("Error while compressing log file.",
    				e1, ErrorManager.FLUSH_FAILURE);
    	}
    	setOutputStream(output);
    }

	private void setPermissions(File file) throws IOException {
		file.createNewFile();
		file.setReadable(false, false);
		file.setReadable(true, true);
		file.setWritable(false, false);
		file.setWritable(true, true);
	}

	private void renameTo(File file1, File file2) throws IOException {
		if (!file1.getName().endsWith(".gz") && file2.getName().endsWith(".gz")) {
			FileInputStream in = new FileInputStream(file1);
			try {
				OutputStream out = new FileOutputStream(file2);
				out = new GZIPOutputStream(out);
				try {
					int read;
					byte[] buf = new byte[1024];
					while ((read = in.read(buf)) >= 0) {
						out.write(buf, 0, read);
					}
				} finally {
					out.close();
				}
			} finally {
				in.close();
			}
			file1.delete();
		} else {
			file1.renameTo(file2);
		}
	}

	/**
     * Transform the pattern to the valid file name, replacing any patterns, and
     * applying generation and uniqueID if present
     * 
     * @param gen
     *            generation of this file
     * @return transformed filename ready for use
     */
    private String parseFileName(int gen) {
        int cur = 0;
        int next = 0;
        boolean hasUniqueID = false;
        boolean hasGeneration = false;

        // TODO privilege code?

        String tempPath = System.getProperty("java.io.tmpdir"); //$NON-NLS-1$
        boolean tempPathHasSepEnd = (tempPath == null ? false : tempPath
                .endsWith(File.separator));

        String homePath = System.getProperty("user.home"); //$NON-NLS-1$
        boolean homePathHasSepEnd = (homePath == null ? false : homePath
                .endsWith(File.separator));

        StringBuilder sb = new StringBuilder();
        pattern = pattern.replace('/', File.separatorChar);

        char[] value = pattern.toCharArray();
        while ((next = pattern.indexOf('%', cur)) >= 0) {
            if (++next < pattern.length()) {
                switch (value[next]) {
                    case 'g':
                        sb.append(value, cur, next - cur - 1).append(gen);
                        hasGeneration = true;
                        break;
                    case 'u':
                        sb.append(value, cur, next - cur - 1).append(uniqueID);
                        hasUniqueID = true;
                        break;
                    case 't':
                        /*
                         * we should probably try to do something cute here like
                         * lookahead for adjacent '/'
                         */
                        sb.append(value, cur, next - cur - 1).append(tempPath);
                        if (!tempPathHasSepEnd) {
                            sb.append(File.separator);
                        }
                        break;
                    case 'h':
                        sb.append(value, cur, next - cur - 1).append(homePath);
                        if (!homePathHasSepEnd) {
                            sb.append(File.separator);
                        }
                        break;
                    case '%':
                        sb.append(value, cur, next - cur - 1).append('%');
                        break;
                    default:
                        sb.append(value, cur, next - cur);
                }
                cur = ++next;
            } else {
                // fail silently
            }
        }

        sb.append(value, cur, value.length - cur);

        if (!hasGeneration && count > 1) {
            sb.append(".").append(gen); //$NON-NLS-1$
        }

        if (!hasUniqueID && uniqueID > 0) {
            sb.append(".").append(uniqueID); //$NON-NLS-1$
        }

        if (gzip && gen > 0) {
        	sb.append(".gz");
        }

        return sb.toString();
    }

    // get boolean LogManager property, if invalid value got, using default
    // value
    private boolean getBooleanProperty(String key, boolean defaultValue) {
        String property = manager.getProperty(key);
        if (null == property) {
            return defaultValue;
        }
        boolean result = defaultValue;
        if ("true".equalsIgnoreCase(property)) { //$NON-NLS-1$
            result = true;
        } else if ("false".equalsIgnoreCase(property)) { //$NON-NLS-1$
            result = false;
        }
        return result;
    }

    // get String LogManager property, if invalid value got, using default value
    private String getStringProperty(String key, String defaultValue) {
        String property = manager.getProperty(key);
        return property == null ? defaultValue : property;
    }

    // get int LogManager property, if invalid value got, using default value
    private int getIntProperty(String key, int defaultValue) {
        String property = manager.getProperty(key);
        int result = defaultValue;
        if (null != property) {
            try {
                result = Integer.parseInt(property);
            } catch (Exception e) {
                // ignore
            }
        }
        return result;
    }

    /**
     * Flush and close all opened files.
     * 
     * @throws SecurityException
     *             if security manager exists and it determines that caller does
     *             not have the required permissions to control this handler,
     *             required permissions include
     *             <code>LogPermission("control")</code> and other permission
     *             like <code>FilePermission("write")</code>, etc.
     */
    @Override
    public void close() {
        // release locks
        super.close();
        allLocks.remove(fileName);
        try {
            FileChannel channel = lock.channel();
            lock.release();
            channel.close();
            File file = new File(fileName + LCK_EXT);
            file.delete();
        } catch (IOException e) {
            // ignore
        }
    }

    /**
     * Publish a <code>LogRecord</code>
     * 
     * @param record
     *            the log record to be published
     */
    @Override
    public void publish(LogRecord record) {
        super.publish(record);
        flush();
        if (limit > 0 && output.getLength() >= limit) {
            AccessController.doPrivileged(new PrivilegedAction<Object>() {
                public Object run() {
                    findNextGeneration();
                    return null;
                }
            });
        }
    }

    /**
     * This output stream use decorator pattern to add measure feature to
     * OutputStream which can detect the total size(in bytes) of output, the
     * initial size can be set
     */
    static class MeasureOutputStream extends OutputStream {

        OutputStream wrapped;

        long length;

        public MeasureOutputStream(OutputStream stream, long currentLength) {
            wrapped = stream;
            length = currentLength;
        }

        public MeasureOutputStream(OutputStream stream) {
            this(stream, 0);
        }

        @Override
        public void write(int oneByte) throws IOException {
            wrapped.write(oneByte);
            length++;
        }

        @Override
        public void write(byte[] bytes) throws IOException {
            wrapped.write(bytes);
            length += bytes.length;
        }

        @Override
        public void write(byte[] b, int off, int len) throws IOException {
            wrapped.write(b, off, len);
            length += len;
        }

        @Override
        public void close() throws IOException {
            wrapped.close();
        }

        @Override
        public void flush() throws IOException {
            wrapped.flush();
        }

        public long getLength() {
            return length;
        }

        public void setLength(long newLength) {
            length = newLength;
        }
    }
}